Conversation
Allows the workspace to invite one to many partners with the same application instance. The user separates each email with a comma to create the email chip. Additional features: - Backspace/del to highlight the previous email chip. Press key again to delete - User can use the left and right arrow keys to select other chips to delete - When tabbing out of the input, the email chip is created or validated to correct - Separate emails are sent to each partner so not share PIP
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds multi-recipient partner invite support: UI accepts multiple emails, introduces MultiValueInput and bulk invite schema/action, routes submit to single or bulk invite flows, creates enrollments, sends invite emails in background, and returns aggregated invite/skip counts. Changes
Sequence Diagram(s)sequenceDiagram
participant User as User/Sheet
participant Schema as Zod Schema
participant Action as Invite Action
participant DB as Database
participant Email as Email Service
participant Audit as Audit Logger
User->>Schema: Submit form (email or emails)
Schema->>Schema: Validate fields & max 50 recipients
Schema-->>User: Validation result
alt Validation passes
User->>Action: Invoke single or bulk invite action
Action->>Action: Deduplicate & normalize recipients
Action->>DB: Fetch program (groups, partners, domains)
loop per recipient
Action->>DB: Check/create Partner & ProgramEnrollment
alt Already enrolled
Action-->>Action: Mark skipped
else
Action->>Audit: Queue audit entry
end
end
par Background tasks
Action->>Email: Send invitation emails (ProgramInvite)
Action->>Audit: Persist audit logs
and
Email-->>Action: Return send results
Audit-->>Action: Return log results
end
Action-->>User: Return invitedCount, skippedCount, details
else Validation fails
Schema-->>User: Return error
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx:
- Around line 462-481: The remove-button's onClick handler inside the Button
(for the X icon) should stop event propagation so the parent span's onClick
doesn't re-select the removed email; update the Button's onClick to accept the
event parameter (e) and call e.stopPropagation() before calling
setSelectedRecipientEmail and setRecipientEmails for the recipientEmail; this
change should be applied where the Button, recipientEmail,
selectedRecipientEmail, setSelectedRecipientEmail, and setRecipientEmails are
referenced.
In `@apps/web/lib/actions/partners/invite-partner.ts`:
- Around line 183-206: Partial failures during the loop currently re-throw after
sending post-invite emails, hiding how many partners were successfully enrolled;
modify the loop using uniqueRecipientEmails and createAndEnrollPartner to catch
per-recipient errors (push failures into an errors array with recipientEmail and
error), continue processing remaining recipients, ensure
createPostInvitePromises (used by waitUntil) is still invoked for any
enrolledPartners, and finally return a result object like { invitedCount:
enrolledPartners.length, invited: enrolledPartners, errors } instead of throwing
so the caller/UI can show accurate success/failure counts; update callers to
expect this summary return shape.
- Around line 83-101: The validation misses ProgramEnrollmentStatus values
"banned", "archived", and "deactivated", so statusMessages lookup can be
undefined and allow duplicate enrollments; update the statusMessages map used in
the invite loop (variable statusMessages) to include entries for "banned",
"archived", and "deactivated" with appropriate messages, ensure the for-loop
that checks existingProgramEnrollment (using existingEnrollmentByEmail and
uniqueRecipientEmails) still throws an Error when any of those statuses are
present, and keep createAndEnrollPartner calls from being executed with
skipEnrollmentCheck: true for those emails.
🧹 Nitpick comments (4)
apps/web/lib/zod/schemas/partners.ts (1)
731-753: Schema looks solid overall; one nit on the dedup + count check.The
superRefinededuplicates before counting, but the.max(50)on theemailsarray (line 736) applies before the superRefine runs — so if someone sends 50 emails in the array plus a differentHowever, note that neither
emailsis required at the schema level — both are optional. The server action guards against an empty set at runtime (line 34-36 ofinvite-partner.ts), but adding a schema-level refinement for "at least one recipient" would give earlier, more descriptive Zod errors. Not blocking since the action handles it.apps/web/lib/actions/partners/invite-partner.ts (1)
184-196: Sequential enrollment — consider batching for large invite lists.Partners are enrolled one-at-a-time in a
forloop. For up to 50 recipients, this means 50 serial DB round-trips. While acceptable for small batches, this could result in noticeable latency at higher counts. No immediate fix needed, but worth noting for future optimization (e.g., parallel batches of 5-10).apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx (2)
323-361:emailInputis processed twice — once bycommitEmailInputand again inline. Works due to React batching but is fragile.
commitEmailInput()at line 328 adds parsed emails torecipientEmailsstate and clearsemailInput— but since React batches state updates, neither change is reflected synchronously. Lines 334-342 then re-parseemailInputto buildfinalRecipientEmails. This works today because state is stale within the same handler, but it's subtle and would break if the function were made async before line 334, or ifcommitEmailInputwere refactored.Consider having
commitEmailInputreturn the committed emails directly soonSubmitcan use them without re-parsing.Sketch
- const commitEmailInput = () => { + const commitEmailInput = (): string[] | false => { const emailCandidates = emailInput .split(",") .map((value) => value.trim()) .filter(Boolean); if (emailCandidates.length === 0) { setEmailInput(""); - return true; + return []; } + const committed: string[] = []; for (const candidate of emailCandidates) { const wasAdded = addRecipientEmail(candidate); if (!wasAdded) { return false; } + committed.push(candidate.trim().toLowerCase()); } setEmailInput(""); - return true; + return committed; };Then in
onSubmit:- const didCommitPendingInput = commitEmailInput(); - if (!didCommitPendingInput) { + const committed = commitEmailInput(); + if (committed === false) { return; } - const finalRecipientEmails = Array.from( - new Set([ - ...recipientEmails, - ...emailInput - .split(",") - .map((value) => value.trim().toLowerCase()) - .filter(Boolean), - ]), - ); + const finalRecipientEmails = Array.from( + new Set([...recipientEmails, ...committed]), + );
217-218: Prefer?? 1over|| 1for invitedCount fallback.
|| 1would coerce a legitimateinvitedCountof0to1. While0shouldn't occur given server validation,??is more precise and conveys the intent of "default only if nullish."Proposed fix
- const invitedCount = data?.invitedCount || 1; + const invitedCount = data?.invitedCount ?? 1;
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx (1)
109-135:⚠️ Potential issue | 🟡 MinorPartial-success recovery path is a dead end.
When some invites succeed and others fail (lines 123-128), the toast shows only counts. The successfully-invited emails remain in the input, and retrying will fail because the backend's pre-check (
throw new Error("Partner X has already been invited…")) aborts the entire request on the first already-enrolled email.Consider either:
- Removing successfully-invited emails from the input and surfacing per-recipient errors so the user can fix and retry only the failures, or
- Closing the sheet on partial success and showing a detailed toast/notification with the failures list.
🤖 Fix all issues with AI agents
In `@packages/ui/src/multi-value-input.tsx`:
- Around line 228-232: handleBlur is causing a duplicate onChange because
commitPendingInput() already calls onChange(next) when it parses new values;
remove the redundant call in handleBlur so it only clears selection and commits
input. Specifically, in the handleBlur function (which calls
setSelectedValue(null) and const next = commitPendingInput()), delete the
subsequent if (next !== values) onChange(next) check so commitPendingInput is
solely responsible for emitting updates.
🧹 Nitpick comments (3)
packages/ui/src/index.tsx (1)
26-27: Nit: export ordering —multi-value-inputshould come aftermenu-item.The surrounding exports are alphabetically sorted. Since
"menu-item"<"multi-value-input"lexicographically, these two lines should be swapped to maintain consistency.Proposed fix
-export * from "./multi-value-input"; export * from "./menu-item"; +export * from "./multi-value-input";packages/ui/src/multi-value-input.tsx (2)
137-157: Consider separating the ResizeObserver setup from the manualcheckWrappedcall.The
[values, inputValue]dependencies cause the observer to be torn down and recreated on every keystroke and chip change. The observer only needs to be created once; the manualcheckWrapped()call is what needs to re-run when values change.Sketch
+ // Manual re-check when content changes + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const children = Array.from(container.children) as HTMLElement[]; + if (children.length <= 1) { setIsWrapped(false); return; } + const tops = children.map((el) => el.offsetTop); + const firstRowTop = Math.min(...tops); + setIsWrapped(tops.some((top) => top - firstRowTop > 2)); + }, [values, inputValue]); + + // ResizeObserver — mount once useEffect(() => { const container = containerRef.current; if (!container) return; - const checkWrapped = () => { const children = Array.from(container.children) as HTMLElement[]; - if (children.length <= 1) { - setIsWrapped(false); - return; - } + if (children.length <= 1) { setIsWrapped(false); return; } const tops = children.map((el) => el.offsetTop); const firstRowTop = Math.min(...tops); setIsWrapped(tops.some((top) => top - firstRowTop > 2)); }; - - checkWrapped(); const observer = new ResizeObserver(checkWrapped); observer.observe(container); return () => observer.disconnect(); - }, [values, inputValue]); + }, []);
67-84: Optional: With React 19,forwardRefis no longer needed.Since this project uses React 19.1.3,
refcan be received as a regular prop. TheforwardRefwrapper still works but is the legacy pattern.Sketch (React 19 style)
-const MultiValueInput = React.forwardRef< - MultiValueInputRef, - MultiValueInputProps ->(function MultiValueInput( - { values, onChange, placeholder, id, className, inputClassName, disabled, normalize = (v) => v.trim(), maxValues, autoFocus }, - ref, -) { +function MultiValueInput({ + values, onChange, placeholder, id, className, inputClassName, disabled, + normalize = (v) => v.trim(), maxValues, autoFocus, ref, +}: MultiValueInputProps & { ref?: React.Ref<MultiValueInputRef> }) { // ... component body unchanged -}); -MultiValueInput.displayName = "MultiValueInput"; +}
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@apps/web/lib/actions/partners/bulk-invite-partners.ts`:
- Around line 125-153: The code sends invites to uniqueRecipientEmails
regardless of whether programEnrollment.createMany actually enrolled them
(skipDuplicates can skip some); fix by deriving the recipient list from the
actual successful enrollments returned by programEnrollment.createMany (or by
querying the created enrollments) instead of using uniqueRecipientEmails, then
pass that filtered list to sendBatchEmail; update references to
programEnrollment.createMany, partners, uniqueRecipientEmails and sendBatchEmail
so only emails for successfully created enrollment records are included.
- Around line 115-176: The async IIFE passed to waitUntil lacks error handling
so failures from sendBatchEmail or recordAuditLog are swallowed; wrap the entire
IIFE body in a try/catch, catch any thrown error, and handle it the same way as
the single-invite flow: log the error (use your logger e.g., processLogger.error
or console.error) with contextual info (programId, workspace.id,
uniqueRecipientEmails), record a failing audit entry or call the existing
error-reporting path (e.g., Sentry.captureException) so the failure is visible,
and ensure the catch does not silently swallow the error without recording or
logging it (keep references to waitUntil, sendBatchEmail, recordAuditLog, and
ProgramInvite to locate changes).
- Around line 63-65: The current all-or-nothing check using program.partners
should be changed to handle partial successes: update the partners query to
explicitly include the nested partner relation so you can read partner.email,
then build a set of alreadyEnrolledEmails from program.partners.map(p =>
p.partner.email); filter the incoming invite list to exclude those emails (or
collect them as skippedEmails), proceed to create invites/enrollments for the
remaining emails, and return/throw a response that includes the skippedEmails so
the caller knows which addresses were ignored; refer to program.partners and the
partners query and make the filtering logic inside the bulk-invite-partners
handler (e.g., bulkInvitePartners) to implement this flow.
🧹 Nitpick comments (4)
packages/ui/src/multi-value-input.tsx (2)
91-106:addValuescallsonChangewith the original reference when all candidates are duplicates.When all normalized candidates already exist in
values,addValuesreturns the originalvaluesreference (line 97). Callers likehandlePaste(line 223-224) then callonChange(next)with this same reference, triggering an unnecessary state update. Not a bug (React/react-hook-form handles same-reference sets gracefully), but worth noting.
138-157: ResizeObserver is recreated on everyvalues/inputValuechange.The
checkWrappedcallback reads from the DOM, so the observer itself doesn't need to be recreated — only the immediatecheckWrapped()call on line 153 needs to re-run. Consider splitting the observer setup (no deps) from the immediate check to avoid unnecessary observer churn.apps/web/lib/zod/schemas/partners.ts (1)
739-743: Consider adding.min(1)to theemailsarray.The schema currently allows an empty
emailsarray. While the UI guards against this, the server action would silently proceed with zero work if called directly withemails: []. Other bulk schemas in this file (e.g.,bulkApprovePartnersSchemaat line 756) enforce.min(1).Proposed fix
export const bulkInvitePartnersSchema = z.object({ workspaceId: z.string(), groupId: z.string().nullish(), - emails: z.array(z.email().trim().min(1).max(100)).max(50), + emails: z.array(z.email().trim().min(1).max(100)).min(1).max(50), });apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx (1)
46-52:InvitePartnerFormDatais unused.The
setValuein theonChangehandler (line 300-303) but is never read in theonSubmithandler —finalEmails[0]is used instead. Consider removing it to reduce confusion.
|
@coderabbitai full review, any breaking changes? |
|
Breaking Changes Analysis
|
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@apps/web/lib/zod/schemas/partners.ts`:
- Around line 740-746: The bulkInvitePartnersSchema currently allows an empty
emails array; update the schema for bulkInvitePartnersSchema to require at least
one email by adding .min(1) to the emails array chain (keeping existing
z.email().trim().min(1).max(100) and .max(MAX_PARTNERS_INVITES_PER_REQUEST)
intact) so server-side validation matches other bulk schemas like
bulkApprovePartnersSchema.
In `@packages/ui/src/multi-value-input.tsx`:
- Around line 91-106: addValues currently only filters candidates against the
existing values array, so duplicate entries within the same candidates batch
(after normalize) can be added and create duplicate chips; update addValues (the
function using normalize, values, maxValues and producing next) to deduplicate
the normalized candidates themselves before pushing into next (e.g., track a
local Set of already-seen items or check next.includes(v) before push) and still
respect maxValues while building next so duplicates from the batch are skipped
and no duplicate keys are produced.
🧹 Nitpick comments (2)
packages/ui/src/multi-value-input.tsx (2)
108-118:onChangeis called unconditionally even when no new values are added.When all parsed values are already present,
addValuesreturns the originalvaluesreference. Line 115 still callsonChange(next), triggering a no-op state update in the parent. Consider guarding with a reference check.Proposed fix
const commitPendingInput = useCallback((): string[] => { const parsed = parseCsvLikeValues(inputValue); if (parsed.length === 0) { setInputValue(""); return values; } const next = addValues(parsed); - onChange(next); + if (next !== values) onChange(next); setInputValue(""); return next; }, [inputValue, values, addValues, onChange]);
248-277: Chip elements are not keyboard-accessible on their own.The
<span>chips useonClickfor selection but have norole,tabIndex, or keyboard event handlers. Users relying on assistive technology cannot interact with individual chips via click or focus. The keyboard navigation via the input element partially mitigates this, but the chips themselves are invisible to screen readers as interactive elements.
Allows the workspace to invite one to many partners (max 50) with the same application instance. The user separates each email with a comma to create the email chip.
Additional features:
Xon each email chip to delete from the list as well.CleanShot.2026-02-12.at.10.42.07.mp4
Summary by CodeRabbit
New Features
Improvements